<?php
/**
 * @package Curly
 * @subpackage Adapters
 * @version 0.9
 * @link http://curly.codeplex.com/
 * @license http://curly.codeplex.com/license The MIT License
 * @author Dawid Zawada
 */

namespace Curly\Adapters
{
	use Curly\AuthenticationMethod;
	use Curly\Error;
	use Curly\HttpClient;
	use Curly\HttpVersion;
	use Curly\ProxyType;
	use Curly\Request;
	use Curly\Response;
	use Curly\Collections\ResourceIndexedCollection;
	use Curly\Exceptions\CurlyException;

    /**
     * HTTP Client adapter that internally uses cURL libraby
     * 
     * @package Curly
 	 * @subpackage Adapters
 	 * @license http://curly.codeplex.com/license The MIT License
     */
	class CurlHttpClient extends HttpClient
	{
	    /**
	     * cURL multi handle resource
	     * 
	     * @var resource
	     */
	    protected $multiHandle;
	    
	    /**
	     * Collection of requests and associated cURL handles
	     * 
	     * @var Curly\Collections\ResourceIndexedCollection
	     */
	    protected $curlRequests;
	    
	    /**
	     * Array of handles to the output files
	     * 
	     * @var array
	     */
	    protected $outputFiles = array();
	    
	    /**
	     * Creates new CurlHttpClient object
	     * 
	     * The constructor throws exception if the cURL libraby is unavailable.
	     * It also initializes cURL multi-handle and fills the collection of default
	     * cURL options ( @see Curly\HttpClient::defaultOptions() ).
	     * 
	     * @see Curly\HttpClient::__construct()
	     * @throws Curly\Exceptions\CurlyException
	     */
	    public function __construct()
	    {
	        parent::__construct();
	        
	        if( !function_exists( "curl_init" ) )
	        {
	            throw new CurlyException( "Curl adapter requires the cURL library to be available" );
	        }
	        
	        $this->multiHandle = curl_multi_init();
	        $this->curlRequests = new ResourceIndexedCollection();
	        
	        $this->setDefaultOptions();
	    }
	    
	    /**
	     * Closes cURL multi handle
	     * 
	     * @see Curly\HttpClient::__destruct()
	     */
	    public function __destruct()
	    {
	        parent::__destruct();
	        
	        if( is_resource( $this->multiHandle ) )
	        {
	            curl_multi_close( $this->multiHandle );
	        }
	    }
	    
	    /**
	     * Fills the default options collection with frequently
	     * used cURL options
	     */
	    protected function setDefaultOptions()
	    {       
	        $this->defaultOptions()->set( CURLOPT_AUTOREFERER, true )
	                               ->set( CURLOPT_HEADER, false )
	                               ->set( CURLOPT_RETURNTRANSFER, true )
	                               ->set( CURLOPT_SSL_VERIFYPEER, false )
	                               ->set( CURLOPT_SSL_VERIFYHOST, 2 );
	    }
	    
	    /**
	     * Executes attached requests in parallel
	     */
	    public function execute()
	    {
	        if( $this->isExecuting() )
	        {
	            return;
	        }
	        $this->setExecuting( true );
	        
	        $this->clientPreProcess();
	        
	        if( $this->curlRequests->count() == 0 )
	        {
	            return;
	        }

	        do 
	        {
                $status = curl_multi_exec( $this->multiHandle, $active );
                curl_multi_select( $this->multiHandle );
                while( ( $info = curl_multi_info_read( $this->multiHandle ) ) !== false )
                {               
                    $request = $this->curlRequests->get( $info["handle"] );
                    $this->requestPostProcess( $request, $info["handle"] );
                }
            }
            while( $status === CURLM_CALL_MULTI_PERFORM || $active || $this->curlRequests->count() );

	        $this->clientPostProcess();
	    }
	    
	    /**
	     * Prepares the client object
	     */
	    protected function clientPreProcess()
	    {
	        $this->setExecuting( true );
	        
	        $this->clearFinishedRequests();
	        $this->populateCurlRequests();
	    }
	    
	    /**
	     * Creates cURL handles for each request attached to the client
	     * and adds them to the cURL multi-handle
	     * 
	     * All requests are moved from the attachedRequests array to the
	     * curlRequests array.
	     * The method also invokes the requestPreProcess method for each request.
	     */
	    protected function populateCurlRequests()
	    {
	        foreach( $this->getAttachedRequests() as $request )
	        {
	            $ch = curl_init();
	            $this->requestPreProcess( $request, $ch );
	            $this->curlRequests->set( $ch, $request );
	            curl_multi_add_handle( $this->multiHandle, $ch );
	        }
	        
	        $this->clearAttachedRequests();
	    }
	    
	    /**
	     * Cleans up the client object
	     */
	    protected function clientPostProcess()
	    {
	        $this->curlRequests->clear();
	        
	        // Close output file handles and clear the array
	        foreach( $this->outputFiles as $outputFile )
	        {
	            if( is_resource( $outputFile["handle"] ) )
	            {
	                fclose( $outputFile["handle"] );
	            }
	        }
	        $this->outputFiles = array();
	        
	        $this->setExecuting( false );
	    }
	    
	    /**
	     * Prepares the given request
	     * 
	     * The method sets cURL options basing on the properties of the
	     * request object.
	     * The method also creates empty Response object and assigns it to 
	     * the request
	     * 
	     * @param Curly\Request $request
	     * @param resource $ch
	     */
	    protected function requestPreProcess( Request $request, $ch )
	    {
	        $request->_setResponse( new Response() );
	        
	        // Set default cURL options
	        curl_setopt_array( $ch, $this->defaultOptions()->getAll() );
	        curl_setopt_array( $ch, $request->options()->getAll() );
	        
	        // URI, method, protocol version
	        curl_setopt( $ch, CURLOPT_URL, $request->getUri() );
	        curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, (string)$request->getMethod() );
	        curl_setopt( $ch, CURLOPT_HTTP_VERSION, $this->getHttpVersionConstant( $request->getHttpVersion() ) );
	        
	        // Auto redirects
	        curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, $request->isAutoRedirect() );
	        if( $request->getMaxRedirections() > 0 )
	        {
	            curl_setopt( $ch, CURLOPT_MAXREDIRS, $request->getMaxRedirections() );
	        }
	        
	        // Output file
	        if( $request->hasOutputFilePath() )
	        {
	            curl_setopt( $ch, CURLOPT_FILE, $this->getOutputFileHandle( $request->getOutputFilePath() ) );
	        }
	        
	        // Timeouts
	        if( $request->getConnectionTimeout() !== null )
	        {
	             curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT_MS, (int)( $request->getConnectionTimeout() * 1000 ) );
	        }
	        if( $request->getTimeout() !== null )
	        {
	            curl_setopt( $ch, CURLOPT_TIMEOUT_MS, (int)( $request->getTimeout() * 1000 ) );
	        }
	        
	        // Referer
	        if( $request->getReferer() != "" )
	        {
	            curl_setopt( $ch, CURLOPT_REFERER, $request->getReferer() );
	        }
	        
	        // User agent
	        if( $request->getUserAgent() != "" )
	        {
	            curl_setopt( $ch, CURLOPT_USERAGENT, $request->getUserAgent() );
	        }
	        
	        // Authentication
	        curl_setopt( $ch, CURLOPT_HTTPAUTH, $this->getAuthMethodConstant( $request->authentication()->getMethod() ) );
	        if( $request->authentication()->getLoginPassword() != "" )
	        {
	            curl_setopt( $ch, CURLOPT_USERPWD, $request->authentication()->getLoginPassword() );
	        }
	        
	        // Cookies
	        if( $request->cookies()->count() )
	        {
	            curl_setopt( $ch, CURLOPT_COOKIE, $request->cookies()->getCookieHeader() );
	        }
	        if( $request->cookies()->hasJarFilePath() )
	        {
	            curl_setopt( $ch, CURLOPT_COOKIEFILE, $request->cookies()->getJarFilePath() );
	            curl_setopt( $ch, CURLOPT_COOKIEJAR, $request->cookies()->getJarFilePath() );
	        }
	        else if( $request->cookies()->isAutoJarFileCreation() )
	        {
	            curl_setopt( $ch, CURLOPT_COOKIEFILE, $this->getAutoJarFilePath() );
	            curl_setopt( $ch, CURLOPT_COOKIEJAR, $this->getAutoJarFilePath() );
	        }
	        
	        // Headers
	        if( $request->headers()->count() )
	        {
    	        $headers = $request->headers()->getAll();
    	        array_walk( $headers, function( &$header, $name ) {
    	            $header = "{$name}: {$header}";
    	        } );
    	        curl_setopt( $ch, CURLOPT_HTTPHEADER, array_values( $headers ) );
	        }
	        
	        // POST vars and files
	        if( $request->files()->count() )
	        {
	            curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
	            curl_setopt( $ch, CURLOPT_POSTFIELDS, $this->getPostFieldsArray( $request ) );
	        }
	        else if( $request->postVars()->count() )
	        {
	            curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, "POST" );
	            curl_setopt( $ch, CURLOPT_POSTFIELDS, $this->getPostFieldsString( $request ) );
	        }
	        
	        // Proxy
	        if( $request->proxy()->getType() != ProxyType::None() )
	        {
	            curl_setopt( $ch, CURLOPT_HTTPPROXYTUNNEL, $request->proxy()->getType() == ProxyType::HTTP() );
	            curl_setopt( $ch, CURLOPT_PROXYTYPE, $this->getProxyTypeConstant( $request->proxy()->getType() ) );
	        }
	        if( $request->proxy()->getHostPort() != "" )
	        {
	            curl_setopt( $ch, CURLOPT_PROXY, $request->proxy()->getHostPort() );
	        }
	        if( $request->proxy()->getLoginPassword() != "" )
	        {
	            curl_setopt( $ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC );
	            curl_setopt( $ch, CURLOPT_PROXYUSERPWD, $request->proxy()->getLoginPassword() );
	        }
	        
	        // Headers callback function
	        curl_setopt( $ch, CURLOPT_HEADERFUNCTION, array( $request->getResponse(), "_headerLineCallback" ) );
	    }
	    
	    /**
	     * Cleans up the given request object
	     * 
	     * The method:
	     * - creates an Error object if cURL reports an error
	     * - sets the final URI (after redirections), the content and the outputFile
	     * - closes the outputFile handle if opened
	     * - fires the RequestCompleted event
	     * - adds new Requests (added by event callback functions) if there are any
	     * - moves the request object from the curlRequests array to the finishedRequests array
	     * - removes the cURL handle from the multi-handle and closes it
	     * 
	     * @param Curly\Request $request
	     * @param resource $ch
	     */
	    protected function requestPostProcess( Request $request, $ch )
	    {
	        $response = $request->getResponse();
	        
	        // Check if error occured
	        if( ( $errno = curl_errno( $ch ) ) != 0 )
	        {
	            $request->_setError( new Error( curl_error( $ch ), $errno ) );
	            $request->_setResponse( null );
	        }
	        else 
	        {
    	        // Set URI, output file path and content
    	        $response->_setUri( curl_getinfo( $ch, CURLINFO_EFFECTIVE_URL ) );
    	        if( $request->hasOutputFilePath() )
    	        {
    	            $response->_setOutputFilePath( $request->getOutputFilePath() );
    	        }
    	        else
    	        {
    	            $response->_setContent( curl_multi_getcontent( $ch ) );
    	        }
	        }
	        
	        // Close output file handle if needed
	        if( $request->hasOutputFilePath() )
	        {
	            $this->closeOutputFileHandle( $request->getOutputFilePath() );
	        }
	        
	        // Fire event
	        $event = $request->requestCompletedEvent();
	        $event( $this, $request, $response );

	        // Add new requests if there are any
	        if( $this->hasAttachedRequests() )
	        {
	            $this->populateCurlRequests();
	        }
	        
	        // Add request to the finished requests array 
	        $request->_setFinished( true );
	        $this->finishedRequests[] = $request;
	        
	        // Close and remove cURL handle
	        $this->curlRequests->remove( $ch );
	        curl_multi_remove_handle( $this->multiHandle, $ch );
	        curl_close( $ch );
	    }
	    
	    /**
	     * Returns value of the cURL constant equivalent to the given 
	     * HttpVersion enumeration
	     * 
	     * @param Curly\HttpVersion $httpVersion
	     * @return int
	     */
	    protected function getHttpVersionConstant( HttpVersion $httpVersion )
	    {
	        switch( $httpVersion )
	        {
	            case HttpVersion::HTTP_1_0(): return CURL_HTTP_VERSION_1_0;
	            
	            default: return CURL_HTTP_VERSION_1_1;
	        }
	    }
	    
	    /**
	     * Returns value of the cURL constant equivalent to the given 
	     * AuthenticationMethod enumeration
	     * 
	     * @param Curly\AuthenticationMethod $authMethod
	     * @return int
	     */
	    protected function getAuthMethodConstant( AuthenticationMethod $authMethod )
	    {
	        switch( $authMethod )
	        {
	            case AuthenticationMethod::Basic(): return CURLAUTH_BASIC;
	            case AuthenticationMethod::Digest(): return CURLAUTH_DIGEST;
	            
	            default: return CURLAUTH_ANY;
	        }
	    }
	    
	    /**
	     * Returns value of the cURL constant equivalent to the given 
	     * ProxyType enumeration
	     * 
	     * @param Curly\ProxyType $proxyType
	     * @return int
	     */
	    protected function getProxyTypeConstant( ProxyType $proxyType )
	    {
	        switch( $proxyType )
	        {
	            case ProxyType::HTTP(): return CURLPROXY_HTTP;
	            case ProxyType::SOCKS4(): return CURLPROXY_SOCKS4;
	            case ProxyType::SOCKS5(): return CURLPROXY_SOCKS5;
	            
	            default: return 0;
	        }
	    }
	    
	    /**
	     * Returns an array containing POST vars and files to upload 
	     * of the given request
	     * 
	     * An @ character is added in front of each file name (cURL requirement)
	     * If a POST var value starts with an @ character, a space
	     * is added in front of it (otherwise it's treated as a file name)
	     * 
	     * @param Curly\Request $request
	     * @return array
	     */
	    protected function getPostFieldsArray( Request $request )
	    {
	        $postVars = $request->postVars()->getAll();
	        $postVars = array_walk( $postVars, function( &$postVar ) {
                if( strpos( $postVar, "@" ) === 0 )
                {
                    $postVar = " {$postVar}";
                }
            } );
	            
            $files = $request->files()->getAll();
            array_walk( $files, function( &$file ) {
                if( strpos( $file, "@" ) !== 0 )
                {
                    $file = "@{$file}";
                }
            } );
            
            return $postVars + $files;
	    }
	    
	    /**
	     * Returns an urlencoded string containing POST vars
	     * of the given request
	     * 
	     * In this case there are no problems with an @ character, bu this
	     * method can be used only if there are no files to upload.
	     * 
	     * @param Curly\Request $request
	     * @return string
	     */
	    protected function getPostFieldsString( Request $request )
	    {
	        $postVars = $request->postVars()->getAll();
	        array_walk( $postVars, function( &$postVar, $name ) {
	            $postVar = rawurlencode( $name ) . "=" . rawurlencode( $name );
	        } );
	        
	        return implode( "&", $postVars );
	    }
	    
	    /**
	     * Returns the handle to the file with the given name
	     * 
	     * Counter is used to avoid closing a file handle that is used by
	     * more than one cURL session
	     * 
	     * @param string $outputFilePath
	     * @return resource
	     */
	    protected function getOutputFileHandle( $outputFilePath )
	    {
	        if( !$this->hasOutputFileHandle( $outputFilePath ) )
	        {
	            $this->outputFiles[$outputFilePath] = array();
	            $this->outputFiles[$outputFilePath]["handle"] = fopen( $outputFilePath, "w" );
	            $this->outputFiles[$outputFilePath]["count"] = 1;
	        }
	        else
	        {    
	            $this->outputFiles[$outputFilePath]["count"]++;
	        }
	        
	        return $this->outputFiles[$outputFilePath]["handle"];
	    }
	    
	    /**
	     * Closes the handle to the file with the given name
	     * 
	     * Counter is used to avoid closing a file handle that is used by
	     * more than one cURL session
	     * 
	     * @param string $outputFilePath
	     */
	    protected function closeOutputFileHandle( $outputFilePath )
	    {
	        if( $this->hasOutputFileHandle( $outputFilePath ) )
	        {
	            if( $this->outputFiles[$outputFilePath]["count"] > 1 )
	            {
	                $this->outputFiles[$outputFilePath]["count"]--;
	            }
	            else
	            {
	                if( is_resource( $this->outputFiles[$outputFilePath]["handle"] ) )
	                {
	                    fclose( $this->outputFiles[$outputFilePath]["handle"] );
	                }
	                
	                unset( $this->outputFiles[$outputFilePath] );
	            }
	        }
	    }
	    
	    /**
	     * Indicates whether the handle to the file with the given name exists
	     * 
	     * @param string $outputFilePath
	     * @return bool
	     */
	    protected function hasOutputFileHandle( $outputFilePath )
	    {
	        return isset( $this->outputFiles[$outputFilePath]["handle"] ) &&
	               is_resource( $this->outputFiles[$outputFilePath]["handle"] );
	    }

	    /**
	     * Returns the version number of the cURL library
	     *
	     * @return string
	     */
	    public static function getCurlVersion()
	    {
	        $curlVersionInfo = curl_version();
	        return $curlVersionInfo["version"];
	    }
	}
}